Skip to content

feat: Run reactive effects - #2508

Closed
schloerke wants to merge 102 commits into
mainfrom
schloerke/async-pr-2182-reimpl
Closed

schloerke wants to merge 102 commits into
mainfrom
schloerke/async-pr-2182-reimpl

Conversation

@schloerke

@schloerke schloerke commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1785.
Fixes #21.
Fixes #1889.

Reimplements #2182 (supersedes it). Related: #837, #39, #907.

Summary

Adopts R Shiny's concurrency model: a flush starts effects and never waits for their async parts. A slow async effect in one session no longer delays other sessions, and a busy session keeps handling messages while its inputs stay stable.

Key changes:

  • No global lock. The session receive loop, invalidate_later, and ExtendedTask completion no longer take reactive.lock(). The receive loop never awaits a flush.
  • request_flush(). Every invalidation requests a flush. The request schedules one merged flush on the next loop pass (loop.call_soon), using a fresh contextvars context so the requester's session and OTel span don't leak into it.
  • Non-waiting flush. ReactiveEnvironment.flush() starts each pending context as a task, in priority order (priority orders starts only, as in R). It keeps strong references to those tasks. Like R's flushReact(), it won't start a second flush while one is active; the active one runs again afterwards.
  • Cycles from the busy count. Input updates and invalidate_later invalidations are sync "cycle start actions". They run only from the session's own output flush, in the order idle → send outputs → start the next action. They never run inline in whichever task queued them, which is the root cause of feat: Implement R Shiny-style concurrency model #2182's invalidate_later hang. Dispatch messages run as their own tasks.
  • Outputs once per cycle. AppSession._flush holds everything while the session is busy. At the end of each cycle it sends one message, even an empty one, which the client needs to settle output progress (see outputProgress.ts). It copies and resets the queues before awaiting the send, so a value set during the send isn't lost.
  • Errors. An exception that escapes an effect task is reported to that effect's session, just as it was when flushes ran inline.
  • Public reactive.flush() still waits until everything settles, so tests and the lock() + flush() idiom keep working. It returns immediately when called from within a flush (from an effect or a flushed callback), or when nothing is pending or running.

Behavior changes app authors may notice:

TODOs

Open (other repos, merge after the release)

Done

Verification

  • New e2e test tests/playwright/shiny/async/concurrent_sessions/: it opens the app in two tabs and starts a 3s async effect in the first. The second tab's 100ms timer must keep ticking while that effect runs. The test fails on main, where the global lock freezes the other tab.
  • tests/pytest/test_concurrency.py adds five in-process tests, all of which fail on main:
    • a slow async effect in one session doesn't block another session;
    • a value shared across sessions doesn't make the setting session's flush wait on the other session's slow effect;
    • while busy, the session answers a dispatch message, keeps input values stable, and applies the queued update once idle;
    • an output set while a send is in flight is kept;
    • the feat: Implement R Shiny-style concurrency model #2182 invalidate_later + queued-update hang doesn't occur.
  • The full pytest suite passes, as does tests/playwright/shiny in chromium. uv run make format check-lint check-types and make check-pyright are clean.

schloerke and others added 6 commits September 25, 2026 11:42
Five in-process tests for the R Shiny-style concurrency model:

- a slow async effect in one session doesn't block another session
- a shared value invalidating another session's slow effect doesn't make
  the invalidating session's flush wait for it
- while an effect is busy, the session still answers dispatch messages,
  keeps input values stable, and applies queued updates once idle
- an output set while a flush is awaiting its send is kept
- an invalidate_later timer firing while an update is queued doesn't hang
  the session (the #2182 busy_count=1 case)

All five fail on main.

Co-authored-by: Josh Taillon <joshua.taillon@posit.co>
Reimplements the concurrency model from #2182 so that a flush starts
effects and never waits for their async parts:

- Remove the global reactive lock from the session loop, invalidate_later
  and ExtendedTask completion. The receive loop never awaits a flush.
- Invalidations call request_flush(), which schedules one coalesced flush
  on the next loop pass with a fresh contextvars context.
- The internal flush starts each pending context as a task (priority
  orders starts only), keeps strong references to those tasks, and, like
  R's flushReact(), doesn't start a second flush while one is active.
- Input updates and invalidate_later invalidations are sync cycle-start
  actions, run only from the session's own output flush: idle -> send
  outputs -> start the next action. Dispatch messages run as tasks.
- AppSession._flush sends once per cycle (held while the session is
  busy), always including the empty message the client needs to settle
  output progress, and copies+resets the queues before awaiting the send.
- Exceptions escaping an effect task are reported to its session.
- Public reactive.flush() waits until settled, but returns right away when
  called from within a flush or when nothing is pending or running.

Co-authored-by: Josh Taillon <joshua.taillon@posit.co>
Effects now run concurrently, so a streamed message can land after one appended later (posit-dev/shinychat#417). Check each message is present; TODO restore the order check once that issue is fixed.
A 3s async effect in one tab must not stop the other tab's timer. Fails on main (the global lock freezes the other tab).
With the flush reentrancy guard, the stream's task starts before later effects append, so the ordered assertion holds again.
@jat255

jat255 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

For reference, I worked up some architectural diagrams to help me understand this a bit more:

Before this PR

main-reactive-cycle

After this PR (as of Oct 8)

reactive-cycle

Comparison to R Shiny approach

r-shiny-arch

jat255
jat255 previously requested changes Sep 30, 2026
Comment thread shiny/_app.py
Comment thread shiny/reactive/_reactives.py
Comment thread shiny/reactive/_core.py Outdated
Comment thread shiny/reactive/_core.py Outdated
Comment thread shiny/session/_session.py
Comment thread shiny/reactive/_core.py Outdated
Comment thread shiny/reactive/_extended_task.py Outdated
Now that a flush doesn't wait for effects, two effects can read an async
calc mid-run, and an effect can be re-invalidated mid-run.

- Calc: a reader from another task waits for the run in flight instead of
  starting a second one (R caches the promise). A second run used to leave
  `_running` stuck True, so the calc never cached again. A run invalidated
  partway through no longer leaves its stale value in front of the fresh
  one.
- Effect: a re-run waits for the previous run to finish, so a slower, older
  run can't finish last and win. (R lets them overlap.)
- `priority` docs: priority orders starts; an async effect's awaited part
  doesn't hold back lower-priority effects.
- Track "inside a flush or effect run" by task identity (a ContextVar
  holding the owning task), not an inherited bool. Tasks an effect starts
  (ExtendedTask bodies, chat tool calls, background loops) now wait for
  dependents instead of returning at once, and don't wait on the effect
  that started them.
- Add flush_pass(): run, or wait for, one complete flush that starts after
  the call. reactive.flush() always does at least one pass and no longer
  spins re-running flushes while one is active.
- ExtendedTask waits for a full flush pass before starting a queued
  invocation, so dependents see every result.
- Rewrite the reactive.lock() docstring: nothing takes it any more.
Fill in the App._sessions_needing_flush placeholder (R's
appsNeedingFlush). A session asks for a flush when it has something to
send: the idle transition, a queued cycle action, send_input_message(),
on_flush() registration, and a finished message handler. After each
reactive flush the app flushes just those sessions, and an error in one
closes that session instead of skipping the rest.

- Message handlers run in session-owned tasks that reactive.flush()
  doesn't wait on, so handlers that await reactive.flush() can't deadlock.
- Requests made by a session's own flush callbacks don't schedule another
  flush (their messages go out in the one being sent), avoiding a loop.
- Only downloads take the busy count; an upload or dynamic route no
  longer pauses the session's cycle.
- `_flush` skips straight to a queued action only when there is one.
Covers every case from the review's repro scripts, plus edge cases:
async calc sharing (incl. invalidated mid-run and shared errors), effect
runs not overlapping, reactive.flush() from effects, tasks, flush
callbacks and message handlers, ExtendedTask queued results, per-session
flush requests and error isolation, one message per cycle (even empty),
timers waiting while busy, busy only for downloads, and session teardown.
The timer test uses a longer interval so it no longer flakes under load.
…r effect

reactive.flush() awaited through a child task of a running flush or effect
(`asyncio.gather()`, or `asyncio.wait_for()` before Python 3.12) waited for
work that couldn't finish until that ancestor did, e.g. a flush pass that
can't start while the current flush is in a flushed callback, or an
effect's queued re-run. It now returns right away whenever the owning
flush or effect run is still going. A task whose starting effect has
finished (an extended task's body) still waits as usual.
A calc invalidated mid-run can be re-run by the next reader while the
stale run still awaits. If the stale run finished last it overwrote the
fresh value, and the calc then served it from the cache. Now only the most
recent run, if it wasn't invalidated, stores its result and ends the
running state; a superseded run returns its result to its own caller only.
A finished message handler no longer requests a flush (its ui.update_*() calls already do, via send_input_message()), and the latest calc run no longer skips caching when invalidated (_invalidated already forces the next read to recompute). Found by mutation testing: reverting either changed no observable behavior.
Mutation-tested all 34 fixes (revert one, run the suite): these tests
cover the ones that were only covered jointly or not at all -- the two busy
gates in _flush, the _start_cycle busy guard, the latest-run calc
bookkeeping, the idle transition ending the cycle, on_flush registration
requesting a flush, busy ending when a run is cancelled, no concurrent
flushes, the fresh flush context, and slow message handlers. The recording
connection also asserts that no output message is sent while busy.
From within an effect, or a task started by one that is still running, it returns right away. Previously this was only in the internal flush_settled() docstring.
After a reactive flush, the app used to await each requesting session's
_flush(), including its websocket send and on_flush callbacks, one after
another inside the global flush. A slow client or callback in one session
delayed every other session's output, the next reactive flush, and anyone
awaiting reactive.flush().

Each session now flushes in its own task, one at a time per session (a
request made mid-send runs once more afterwards, so messages stay in
order). An error closes that session only. reactive.flush() still waits
for these tasks, so its "settled" meaning is unchanged. A flush callback
that awaits reactive.flush() returns right away rather than waiting on its
own session's task.

Also skip the send when no cycle has ended and nothing is queued (R's
hasPendingUpdates()): each input update now yields one output message
instead of a value message plus an empty one.
request_flush() used get_running_loop(), so a value set from another
thread silently scheduled no flush, and calling loop.call_soon() from that
thread would not have been safe anyway. The environment now remembers the
loop Shiny runs on and hands requests from other threads (including a
thread running its own loop) to it with call_soon_threadsafe(); requests
are ignored if there is no loop yet or it has closed.

Only the request is thread-safe: Value.set()'s docs now say to call it on
the loop's thread, or via loop.call_soon_threadsafe(value.set, x).
Slow clients and slow on_flush callbacks delaying only their own session;
the next reactive flush not waiting on a stuck send; per-session sends
never overlapping and staying in order across cycles; reactive.flush()
waiting for pending sends; concurrent flush callbacks in two sessions;
a session closing mid-send with a re-run pending; a failed send closing
only its session; one output message per update. For threads: set() via
call_soon_threadsafe and direct, a thread with its own loop, merged
requests, no loop yet, a closed loop, and a new loop after the old one
closed. Every new code path was mutation-tested.
@jat255
jat255 dismissed their stale review October 1, 2026 19:17

PR has been updated

@schloerke
schloerke marked this pull request as ready for review October 1, 2026 19:17
@schloerke
schloerke added this pull request to stack #2516 October 1, 2026 19:36
… function

An error in `fn` closed the session with only the error message on stderr.
Now the traceback is printed first, as for an error in an effect. Also
document why `fn` must be synchronous.
`lock()` is usually called in a loop, so warning on every call floods the
console.
The span started with the current span as its parent, but it starts in
whichever task invalidated the session's effect, often another session's
effect or an earlier cycle of this one. One session's cycle then landed in
another session's trace. The first cycle's parent is now `session_start`
and later cycles start a trace of their own, as documented.
…dge cases

- A `reactive.calc` that reads itself cached the base case's value, because
  each nested run counted as a superseding one. Only the outermost run of a
  task writes the cache now.
- A `reactive.on_flushed()` callback that raised stopped the round-finished
  callbacks, including the app's output-flush hook, so the sessions' outputs
  were never sent. The error is printed and the other callbacks still run.
- The app's round-finished hook is unregistered when its last session ends,
  so an `App` no longer stays referenced by the reactive environment.
…ssion-end-cancel

# Conflicts:
#	tests/pytest/test_concurrency.py
…c-run-once-when-idle

# Conflicts:
#	tests/pytest/test_concurrency.py
…k-flush-replacement

# Conflicts:
#	shiny/reactive/_core.py
#	tests/pytest/test_concurrency.py
…andler tasks

- Registering `session.on_flushed()` now requests an output flush, like
  `on_flush()` does, so a callback registered outside a cycle (from a message
  handler after an `await`, or a background task) runs; `ui.insert_ui()` from
  those places reaches the client.
- A flush requested from within the `on_flushed` callbacks is sent in one more
  message right after them, without running the callbacks again. Before, an
  `on_flushed(once=False)` callback that called `ui.update_*()` looped forever.
- Message-handler tasks are cancelled when the session ends, and an error
  escaping one (e.g. the response couldn't be sent) is printed and closes the
  session, instead of 'Task exception was never retrieved'.
- Note on `uploadEnd` that it sets its input mid-cycle, as Shiny for R does.
CHANGELOG: say 'round' where 'flush' meant one, list every early-return case
of `reactive.flush()`, note the new timing of `reactive.on_flushed()`, drop
the cross-thread `set()` endorsement and the #1785 input-handling claim, and
add entries for #1889/#21 and for uploads no longer marking the session busy.
Docstrings and the bundled skill use the same vocabulary.
…k-flush-replacement

# Conflicts:
#	CHANGELOG.md
#	shiny/reactive/_core.py
#	tests/pytest/test_concurrency.py
…-otel-session-span

# Conflicts:
#	CHANGELOG.md
…kmark-on-restore-order

# Conflicts:
#	tests/pytest/test_concurrency.py
@schloerke

Copy link
Copy Markdown
Collaborator Author

Continued in #2549. On 2026-10-13 this PR's branch was briefly pushed at the same commit as its base while merging review fixes up the stack, so GitHub marked it closed and it cannot be reopened. The branch has been restored to its proper history; the stack is now #2550 (#2549 → #2545 → #2546 → #2520 → #2547 → #2548 → #2525).

jat255 added a commit to posit-dev/py-shiny-site that referenced this pull request Oct 15, 2026
reactive.lock() is deprecated (posit-dev/py-shiny#2520) and setting a
value schedules a flush (posit-dev/py-shiny#2508), so the
update_dashboard() example no longer takes the lock or flushes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants